16장 한 자리에 여러 타입 — 다형성
출처: API Design Patterns (Manning, JJ Geewax) 16장을 입문자용으로 쉽게 풀어 씀.
이 장에서 딱 4가지만 기억하면 됩니다. 1. 비슷한 것들을 한 자리에 모으는 "다형성 리소스"가 뭔지 설명한다. 2. type 필드로 세부 종류를 구분하는 방법을 설명한다. 3. 데이터를 담는 3가지 방식과 검증 원칙을 설명한다. 4. "다형성 메서드"는 왜 피해야 하는지 구분한다.
들어가기 전에 — 이런 적 있죠?
카카오톡 채팅방을 떠올려 봅시다.
거기엔 글자 메시지도 있고, 사진도 있고, 동영상도 있고, 음성 메시지도 섞여 있죠.
그런데 만약 서버를 이렇게 만들었다고 해봅시다. 글자 목록 따로, 사진 목록 따로, 동영상 목록 따로, 음성 목록 따로.
이러면 채팅방 하나를 시간순으로 쭉 보여주려고 무려 4번이나 서버에 물어봐야 합니다.
ListTextMessages() 글자만
ListPhotoMessages() 사진만
ListVideoMessages() 동영상만
ListAudioMessages() 음성만
이렇게 짜면 나중에 "음악 메시지"가 추가될 때마다 목록 함수를 또 하나 만들어야 합니다.
그게 바로 이번 장에서 풀려는 문제입니다.
해법은 하나입니다. "이것들은 전부 메시지다"라고 한 묶음으로 보는 것.
ListMessages() 글자, 사진, 동영상, 음성을 한 번에!
이렇게 "겉모습은 하나인데 속은 여러 종류"를 다루는 방식을 다형성이라고 부릅니다.
16.1 다형성이 뭐길래
먼저 일상 비유부터
배달 앱에서 "음식"을 생각해 봅시다.
치킨, 피자, 떡볶이, 커피는 전부 다른 음식이지만, 주문서에는 똑같이 "메뉴 항목"으로 줄줄이 적힙니다.
장바구니는 "이게 치킨인지 커피인지" 일일이 따지지 않고 그냥 "메뉴 항목"으로 한 줄씩 담습니다.
이렇게 겉(메뉴 항목)은 같고 속(치킨/커피)은 다른 것을 한 자리에서 다루는 게 다형성입니다.
R1 풀이: 다형성 = 겉모습(공통 인터페이스)은 하나인데 속 내용은 여러 종류일 수 있는 것. 마치 "택배 상자"는 다 똑같이 생겼지만 안에 든 물건은 책일 수도, 옷일 수도, 그릇일 수도 있는 것과 같다.
표로 한눈에 연결
| 일상 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 장바구니에 치킨·커피를 같이 담음 | 한 목록에 text·photo 메시지를 같이 나열 | 종류별로 속 내용이 다르다는 걸 잊으면 안 됨 |
| 택배 상자는 똑같이 생김 | Message라는 공통 모양 | 상자(겉)와 내용물(속)을 헷갈리지 말 것 |
코드로 살짝 — 짧게
프로그래밍에서 다형성은 이렇게 생겼습니다. (TypeScript)
interface Shape {
countSides(): number;
}
class Triangle implements Shape {
countSides() { return 3; }
}
class Square implements Shape {
countSides() { return 4; }
}
여기서 핵심은 아래입니다.
function countSides(shape: Shape): number {
return shape.countSides();
}
이 함수는 받은 게 삼각형인지 사각형인지 신경 쓰지 않습니다.
그냥 "도형(Shape)이니까 변의 개수를 물어보면 답하겠지" 하고 넘어갑니다.
이 장에서 하려는 건, 이 편리함을 코드 안이 아니라 API(서버와 대화하는 창구)에도 쓰는 것입니다.
다형성이 진짜 쓰이는 실제 시스템들
1. 메시징 앱 (카카오톡, Slack)
Message: text, image, video, audio, file, location, sticker
ListMessages로 채팅 내역을 시간순 통합 나열
2. 결제 시스템
PaymentMethod: credit_card, bank_transfer, mobile_pay, crypto
ListPaymentMethods로 사용자의 모든 결제 수단 나열
3. 알림 시스템
Notification: email, push, sms, in_app
ListNotifications로 모든 타입의 알림을 통합 나열
4. CI/CD 파이프라인
Step: build, test, deploy, approval, notification
ListSteps로 파이프라인의 모든 단계를 순서대로 나열
5. CMS (콘텐츠 관리)
Block: text, image, video, code, quote, table
ListBlocks로 페이지의 모든 콘텐츠 블록을 순서대로 나열
16.2 다형성 리소스의 개념
다시 실패 장면부터
앞에서 봤듯이, 종류별로 목록 함수를 따로 만들면 함수가 끝없이 늘어납니다.
그래서 나온 게 다형성 리소스입니다.
R1 풀이: 다형성 리소스 = "겉은 하나(예: Message)인데, 안에 type이라는 꼬리표를 달아 속 종류를 구분하는 데이터 묶음". 옷가게에서 옷걸이는 다 똑같지만 거기 달린 작은 태그(상의/하의/외투)로 종류를 아는 것과 같다.
R1 풀이: type 필드 = 리소스가 어떤 세부 종류인지 적어두는 글자 칸. 택배 상자 겉면에 붙은 "도서" "의류" 스티커 같은 것.
핵심 아이디어는 이렇습니다.
겉은 Message 하나로 통일한다.
그 안에 type 칸을 둬서 text인지 photo인지 audio인지 video인지 적는다.
표로 연결
| 일상 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 옷걸이는 같고 태그로 종류 구분 | Message는 같고 type으로 종류 구분 | type을 안 보고 속을 꺼내면 엉뚱한 칸을 읽음 |
| 택배 겉면 스티커 "도서/의류" | type: 'text' \| 'photo' |
스티커가 틀리면 분류 전체가 꼬임 |
다형성 리소스의 좋은 점 3가지
- 중복 방지: ListTextMessages, ListPhotoMessages 같은 걸 만들 필요 없이 ListMessages 하나면 끝.
- 확장성: 새 종류(예: sticker)가 생겨도 목록 함수를 새로 만들 필요 없음. type 값만 하나 늘리면 됨.
- 통합 목록: 채팅방의 모든 메시지를 한 번에 시간순으로 쭉 나열 가능.
기억할 체크포인트
여러 종류를 함께 나열해야 할 때 다형성 리소스를 쓴다.
type 칸으로 종류를 구분하고, 서버가 그 값이 올바른지 확인(검증)한다.
표준 메서드(만들기·읽기·수정·삭제·목록)는 종류와 상관없이 똑같이 적용된다.
16.3 직접 만들어 보기
16.3.1 다형성 리소스를 써야 할지 말지
무턱대고 한 묶음으로 합치면 나중에 후회합니다.
먼저 표준 규칙(기본 안전판)부터 단순하게 봅시다. 아래 3개 질문을 순서대로 던지면 됩니다.
Q1: 여러 종류를 한 목록에 함께 나열해야 하는가?
아니오 → 그냥 따로따로 만든다 (다형성 불필요)
예 → Q2: 각 종류가 "큰 분류의 특수한 한 종류"인가?
아니오 → 따로 만든다
(예: 채팅방과 공지방은 근본이 다름)
예 → Q3: 종류끼리 동작(메서드)이 같은가?
예 → 다형성 리소스 사용
조금 다름 → 다형성 리소스 가능
(type을 보고 분기 처리)
판단 기준 한 문장: "PhotoMessage는 Message의 특수한 한 종류인가?"에 자신 있게 "예"라고 답할 수 있으면 묶어도 좋습니다.
묶어도 좋은 예 (올바른 예)
1. 채팅 앱
PhotoMessage는 Message의 특수한 종류인가? 예
ListMessages 하나로 모든 종류 나열
2. 결제 수단
CreditCard는 PaymentMethod의 특수한 종류인가? 예
ListPaymentMethods 하나로 모든 결제 수단 나열
3. CMS 블록
ImageBlock은 Block의 특수한 종류인가? 예
ListBlocks 하나로 모든 블록 나열
묶으면 안 되는 예 (잘못된 예)
1. 채팅방 vs 공지방
채팅방: 양방향, 모두 발언 가능
공지방: 단방향, 관리자만 발신, 구독/해지 모델
공지방은 채팅방의 특수한 종류인가? 아니오 → 따로
2. 파일 vs 폴더
파일: 데이터 저장, 다운로드 가능
폴더: 다른 항목을 담는 통
폴더는 파일의 특수한 종류인가? 아니오 → 따로
3. 사용자 vs 서비스 계정
사용자: 로그인, 프로필, 비밀번호 변경
서비스 계정: API 키, 봇 동작, 비밀번호 없음
인증 방식이 완전히 다름 → 따로
16.3.2 속을 어떻게 담을까
먼저 type 칸부터
interface Message {
id: string;
sender: string;
type: 'text' | 'photo' | 'audio' | 'video';
}
왜 type을 글자(문자열)로 둘까?
만약 type을 고정 목록(열거형, enum)으로 박아두면, 나중에 종류를 추가/삭제할 때 기존 사용자 프로그램이 깨질 수 있습니다.
R1 풀이: 열거형(enum) = 미리 정해둔 값만 고를 수 있는 고정 메뉴판. "이 칸엔 빨강·파랑·노랑만 적을 수 있어"처럼 후보를 못 박아둔 것.
반면 글자로 두면 새 종류(예: 'sticker')를 슬쩍 추가해도 기존 코드가 멀쩡합니다.
type은 한번 정하면 못 바꿉니다(불변).
R1 풀이: 불변 = 한번 만들어지면 바꿀 수 없는 성질. 출생신고서의 생년월일처럼 나중에 고치면 모든 게 꼬이는 값.
왜냐하면 다른 곳에서 "이건 동영상 메시지야"를 믿고 연결해 뒀는데, 갑자기 글자 메시지로 바꿔버리면 그 연결이 다 어긋나거든요.
속을 담는 3가지 방식
방식 1 — 슈퍼셋: 모든 종류의 칸을 한 곳에 다 모아둠.
R1 풀이: 슈퍼셋 = 모든 종류가 쓸 칸을 빠짐없이 한 통에 다 모은 것. 가족 공용 서랍에 누구 물건이든 다 넣어두는 것과 비슷.
interface Message {
id: string;
sender: string;
type: 'text' | 'photo' | 'audio' | 'video';
text?: string; // text일 때만 사용
photoUri?: string; // photo일 때만 사용
videoUri?: string; // video일 때만 사용
audioUri?: string; // audio일 때만 사용
}
쉽지만, 칸이 자꾸 늘어나 통이 뚱뚱해지는 단점이 있습니다.
방식 2 — 단일 content 칸: 칸은 하나, 의미는 type에 따라 달라짐.
interface Message {
id: string;
sender: string;
type: 'text' | 'photo' | 'audio' | 'video';
content: string; // text면 글자, photo면 주소(URI)
}
방식 3 — content + Media 묶음 (권장): 글자면 글자, 미디어면 따로 정리한 묶음.
interface Message {
id: string;
sender: string;
type: 'text' | 'image' | 'audio' | 'video';
content: string | Media; // text면 string, 나머지면 Media
}
interface Media {
contentUri: string;
contentType: string; // "video/mp4" 등
}
R1 풀이: URI = 인터넷에서 어떤 파일이 어디 있는지 가리키는 주소. 집 주소가 사람을 찾게 해주듯, 파일을 찾게 해주는 주소.
도형 예제 — 종류마다 필요한 숫자가 다를 때
정사각형은 한 변 길이만 알면 되고, 원은 반지름만, 삼각형은 밑변과 높이가 필요합니다.
interface Shape {
id: string;
type: 'square' | 'circle' | 'triangle';
dimension: SquareDimension | CircleDimension | TriangleDimension;
}
interface SquareDimension { length: number; }
interface CircleDimension { radius: number; }
interface TriangleDimension { base: number; height: number; }
데이터 검증 — 엉뚱한 값이 들어오면
비유: 택배 받을 때 "도서"라고 적힌 상자인데, 안에 옷이 들어 있으면 어떻게 할까? 도서 칸만 확인하고, 굴러들어온 옷은 그냥 무시한다.
잘못된 예 vs 올바른 처리:
정사각형인데 반지름 값까지 같이 보내면?
CreateShape({ type: 'square', dimension: { radius: 10, length: 10 } })
필요한 length만 확인하고, 엉뚱한 radius는 그냥 무시 (에러 안 냄)
사진 메시지인데 content가 주소가 아니라 그냥 글자면?
CreateMessage({ type: 'photo', content: '그냥 텍스트' })
이건 에러를 낸다 (photo면 content는 반드시 올바른 주소여야 하니까)
원칙: 엉뚱한 값이 끼어 있어도 곧장 에러부터 내지 말고, 꼭 필요한 값이 있는지부터 확인한 다음, 필요 없는 값은 조용히 무시한다.
단, 꼭 있어야 할 값 자체가 없거나 형식이 틀렸으면 그때는 에러를 낸다.
16.3.3 다형성 동작 — 메서드는 평소처럼
좋은 소식이 있습니다.
다형성 리소스라고 해서 메서드를 특별하게 만들 필요는 없습니다.
평소 쓰던 표준 메서드, 사용자 정의 메서드를 그대로 쓰면 됩니다.
abstract class ShapeApi {
@post("/{id=shapes/*}:countSides")
CountShapeSides(req: CountShapeSidesRequest): CountShapeSidesResponse;
}
호출하면 서버가 type을 보고 알아서 맞는 답을 줍니다.
CountShapeSides({ id: "shapes/square-1" }) { sides: 4 }
CountShapeSides({ id: "shapes/triangle-1" }) { sides: 3 }
겉으론 같은 함수 하나를 부르지만, 속(square냐 triangle이냐)에 따라 답이 달라지는 거죠.
16.3.4 다형성 "메서드"는 왜 피해야 할까
여기서 헷갈리기 쉬운 함정이 하나 있습니다.
리소스를 다형성으로 묶는 건 좋지만, 메서드까지 하나로 합치는 건 위험합니다.
R1 풀이: 다형성 메서드 = 종류를 안 가리고 "아무 리소스나 처리해주는" 만능 함수. 편해 보이지만, 종류마다 사정이 다른데 한 함수가 다 떠안으면 탈이 난다.
만능 삭제의 유혹
abstract class GenericApi {
@delete("/{id=*/*}") // 모든 리소스 종류에 다 매칭
DeleteResource(req: DeleteResourceRequest): void;
}
"이거 하나면 다 지울 수 있으니 훨씬 간단하잖아? 왜 안 쓰지?"
위험한 이유 2가지
1. 종류마다 삭제 동작이 달라질 수 있다 (동작 분화).
R1 풀이: 동작 분화 = 처음엔 똑같던 동작이 시간이 지나며 종류별로 조금씩 갈라지는 현상. 쌍둥이가 자라면서 성격이 달라지는 것과 같다.
DeleteChatRoom() 채팅방은 바로 안 지우고 표시만 해두는 방식 + 한 번 더 확인
DeleteMessage() 메시지는 즉시 진짜로 삭제
하나의 DeleteResource()로 합쳐버리면
이 차이를 표현할 자리가 사라진다.
R1 풀이: 위 "표시만 해두는 방식"은 진짜로 지우지 않고 "지움" 도장만 찍어두는 것(나중에 되살릴 수 있게). "한 번 더 확인"은 정말 지울 건지 사용자에게 재차 묻는 것.
2. 동작을 한 번 바꾸면 모든 종류에 한꺼번에 번진다.
채팅방 삭제에만 "정말 지울래요?" 확인을 넣고 싶었는데
DeleteResource()를 고치면 메시지 삭제에도 그 확인이 붙어버린다.
의도하지 않은 부작용!
비유: 집 전체 메인 스위치 하나로 모든 방 불을 켜고 끈다고 해봅시다. 침실만 어둡게 하고 싶은데 스위치를 만지면 거실·주방까지 다 꺼져버립니다. 방마다 스위치가 따로 있어야 안전하죠.
원칙: 다형성 리소스에는 집중하되, 다형성 메서드는 피하라.
| 일상 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 집 메인 스위치 하나로 전체 제어 | DeleteResource() 만능 삭제 | 한 종류만 바꾸려 해도 전부 영향 |
| 방마다 스위치 따로 | DeleteMessage / DeleteChatRoom 분리 | 메서드가 늘지만 안전함 |
16.3.5 완성된 API 모습
지금까지 배운 걸 합치면 이런 채팅 API가 됩니다.
채팅방(ChatRoom)은 평범한 리소스, 메시지(Message)는 다형성 리소스입니다.
abstract class ChatRoomApi {
static version = "v1";
static title = "Chat Room API";
@post("/chatRooms")
CreateChatRoom(req: CreateChatRoomRequest): ChatRoom;
@get("/{id=chatRooms/*}")
GetChatRoom(req: GetChatRoomRequest): ChatRoom;
@get("/chatRooms")
ListChatRooms(req: ListChatRoomsRequest): ListChatRoomsResponse;
@post("/{parent=chatRooms/*}/messages")
CreateMessage(req: CreateMessageRequest): Message;
@get("/{id=chatRooms/*/messages/*}")
GetMessage(req: GetMessageRequest): Message;
@get("/{parent=chatRooms/*}/messages")
ListMessages(req: ListMessagesRequest): ListMessagesResponse;
@delete("/{id=chatRooms/*/messages/*}")
DeleteMessage(req: DeleteMessageRequest): void;
}
메시지 종류는 type으로 구분합니다.
interface Message {
id: string;
sender: string;
type: 'text' | 'image' | 'audio' | 'video';
content: string | Media;
createdAt: DateTime;
}
interface Media {
uri: string;
contentType: string; // "image/jpeg", "video/mp4" 등
sizeBytes: number;
}
실제로 불러보면 (HTTP 예시, 최소한만)
텍스트 메시지 만들기:
POST /chatRooms/room-1/messages
{ "sender": "users/alice", "type": "text", "content": "안녕하세요!" }
이미지 메시지 만들기:
POST /chatRooms/room-1/messages
{
"sender": "users/bob",
"type": "image",
"content": { "uri": "https://.../schedule.png", "contentType": "image/png", "sizeBytes": 245760 }
}
모든 종류를 한 번에 나열:
GET /chatRooms/room-1/messages
[ { type: "text", ... }, { type: "image", ... } ]
특정 종류만 골라 보기:
GET /chatRooms/room-1/messages?filter=type='image'
이미지 메시지만 반환
16.4 좋기만 한 건 아니다 — 트레이드오프
다형성은 편하지만 공짜는 아닙니다.
R1 풀이: 트레이드오프 = 한쪽이 좋아지면 다른 쪽이 나빠지는 맞교환. 라면을 빨리 끓이려 불을 세게 하면 면이 덜 익는 것처럼, 모든 선택엔 대가가 있다.
핵심 한 줄: 한번 한 묶음으로 묶으면, 나중에 떼어내기가 무척 어렵습니다.
16.4.1 떼어내기 어려운 이유
처음엔 Message 하나로 text, image, video를 잘 관리했다.
그런데 시간이 지나니
video에만 "변환 상태", "스트리밍 주소" 칸이 필요해지고
text에만 "굵게/기울임 서식", "링크 미리보기" 칸이 필요해졌다.
Message 통이 점점 뚱뚱해진다.
이제 떼어내려고 하면?
기존: ListMessages → 모든 종류
변경: ListTextMessages, ListVideoMessages 로 쪼갬
→ 기존 사용자 프로그램이 전부 깨진다! (breaking change)
R1 풀이: breaking change = 바꾸는 순간 기존 사용자 코드가 망가지는 변경. 집 현관 비밀번호를 말없이 바꿔 가족이 다 못 들어오게 되는 상황.
한 걸음 더 ▸ 그래서 묶기 전에 R8의 표준 규칙(16.3.1의 세 질문)으로 신중히 판단하는 게 중요합니다. 처음 묶는 비용보다 나중에 떼는 비용이 훨씬 크기 때문입니다.
16.4.2 새 종류를 추가할 때 주의할 점
새 종류(예: sticker)를 넣는 일은 서버 쪽에선 쉽습니다.
type에 'sticker'를 추가하고 속 구조만 정하면 끝.
문제는 사용자 프로그램(클라이언트)입니다.
처음 보는 종류가 와도 당황하지 않게 만들어야 합니다.
올바른 예:
switch (message.type) {
case 'text': renderText(message); break;
case 'image': renderImage(message); break;
default: renderGeneric(message); // 모르는 종류도 일단 표시!
}
잘못된 예:
if (message.type === 'text') { ... }
else if (message.type === 'image') { ... }
// else가 없음 → sticker 메시지가 화면에서 사라짐!
비유: 식당에서 처음 보는 외국 손님이 와도, 메뉴판을 들이밀며 "일단 앉으세요" 하는 게 좋은 응대입니다. "처음 보는 손님은 무시"하면 그 손님은 가게에서 증발해 버리죠.
16.5 직접 풀어 보기 — 연습문제
Q1: 결제 시스템에서 신용카드·계좌이체·모바일페이를 다형성 리소스로 만들 때 type과 데이터 구조를 설계해 보세요.
풀이: ```typescript interface PaymentMethod { id: string; type: 'credit_card' | 'bank_transfer' | 'mobile_pay'; displayName: string; details: CreditCardDetails | BankTransferDetails | MobilePayDetails; }
interface CreditCardDetails { last4: string; brand: string; expMonth: number; expYear: number; } interface BankTransferDetails { bankName: string; accountNumber: string; } interface MobilePayDetails { provider: string; phoneNumber: string; } ```
Q2: 폴더와 파일을 하나의 다형성 리소스(Item)로 묶을 수 있을까요?
풀이: 묶지 않는 게 좋습니다. 폴더는 다른 항목을 담는 통이고, 파일은 데이터를 저장하는 단위입니다. 폴더엔 "하위 항목 추가/제거" 같은 동작이 필요하지만 파일엔 필요 없습니다. 동작이 근본적으로 다르므로 따로 만드는 게 맞습니다.
Q3: 만능 삭제(DeleteResource) 같은 다형성 메서드가 안전한 경우도 있을까요?
풀이: 모든 종류가 정확히 똑같은 방식으로 삭제되고, 앞으로도 절대 달라지지 않을 게 확실할 때만 안전합니다. 하지만 그런 확신은 현실에서 거의 불가능하므로, 실무에선 종류별 삭제 메서드를 따로 두는 게 안전합니다.
부록 A: 용어 한 줄 정리
| 용어 | 영문 | 한 줄 뜻 |
|---|---|---|
| 다형성 | Polymorphism | 겉은 하나, 속은 여러 종류일 수 있는 것 |
| 다형성 리소스 | Polymorphic Resource | type 칸으로 속 종류를 구분하는 데이터 묶음 |
| 다형성 메서드 | Polymorphic Method | 종류 안 가리는 만능 함수 (비권장) |
| 슈퍼셋 | Superset | 모든 종류의 칸을 한 통에 다 모은 구조 |
| 불변 타입 | Immutable Type | 만든 뒤 못 바꾸는 type 칸 |
부록 B: 한눈에 비교
| 항목 | 종류별 따로 만들기 | 다형성 리소스 |
|---|---|---|
| 메서드 수 | 종류 수 × 메서드 수 | 한 세트 |
| 통합 나열 | 불가 (각각 호출) | ListResource 하나 |
| 종류별 동작 | 따로 제어 가능 | 공통 동작만 |
| 확장성 | 새 종류 = 새 메서드 세트 | 새 종류 = type 값 추가 |
| 복잡도 | 종류 늘수록 커짐 | 일정 |
부록 C: 더 찾아볼 거리 (지금 몰라도 됨)
| 자료 | 설명 |
|---|---|
| 데이터 타입과 기본값 (앞 장) | 열거형 대신 글자를 쓰는 이유 |
| 필터링 (앞 장) | type 칸으로 특정 종류만 골라 나열하기 |
| 논리 삭제 (앞 장) | 다형성 메서드가 위험한 이유의 실제 예 |
다음 장 예고: 16장은 이 책의 마지막 장입니다. 여기까지 잘 따라오셨다면, 리소스를 설계하는 큰 그림이 이제 손에 잡힐 거예요.
클릭하거나 Space를 눌러 뒤집기